@kevinpeckham/barkdown 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +177 -0
- package/dist/adapter.d.ts +78 -0
- package/dist/adapter.js +48 -0
- package/dist/index.d.ts +29 -0
- package/dist/index.js +30 -0
- package/dist/parse.d.ts +17 -0
- package/dist/parse.js +32 -0
- package/dist/serialize-emphasis.d.ts +23 -0
- package/dist/serialize-emphasis.js +186 -0
- package/dist/serialize-footnote-defs.d.ts +15 -0
- package/dist/serialize-footnote-defs.js +106 -0
- package/dist/serialize.d.ts +26 -0
- package/dist/serialize.js +1219 -0
- package/package.json +74 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Kevin Peckham
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
# barkdown
|
|
2
|
+
|
|
3
|
+
**A Markdown ⇄ DOM round-trip codec, guaranteed to invert marked.** Parse markdown to HTML with [marked](https://github.com/markedjs/marked); edit it as a live DOM (contenteditable, WYSIWYG, programmatic transforms); serialize the DOM back to markdown that is *provably* the same document.
|
|
4
|
+
|
|
5
|
+
barkdown is the sibling of [@kevinpeckham/barkup](https://github.com/kevinpeckham/barkup):
|
|
6
|
+
barkup guards the tree's identity; barkdown guards the prose's.
|
|
7
|
+
|
|
8
|
+
```ts
|
|
9
|
+
import { toDom, toMarkdown, roundTrip } from "@kevinpeckham/barkdown";
|
|
10
|
+
|
|
11
|
+
const html = toDom("# Hello\n\nSome **bold** prose.\n"); // marked + footnotes
|
|
12
|
+
element.innerHTML = html; // …user edits the DOM…
|
|
13
|
+
const markdown = toMarkdown(element); // back to markdown
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
The serializer came out of a production blog CMS — a contenteditable
|
|
17
|
+
WYSIWYG whose every keystroke round-trips through this exact pair of
|
|
18
|
+
functions — and the round-trip guarantee is enforced by a corpus suite
|
|
19
|
+
plus fast-check property tests (~2,000 random documents per run).
|
|
20
|
+
|
|
21
|
+
## The four guarantees
|
|
22
|
+
|
|
23
|
+
1. **Canonical identity.** For markdown in barkdown's canonical dialect
|
|
24
|
+
(ATX headings, `**bold**`/`*italic*`, `-` bullets, fenced code, GFM
|
|
25
|
+
tables), `toMarkdown(toDom(md)) === md` — byte for byte.
|
|
26
|
+
2. **Fixed-point convergence.** For ANY input markdown, one round trip
|
|
27
|
+
reaches the canonical form and a second round trip is byte-identical
|
|
28
|
+
to the first — serialize∘parse is idempotent.
|
|
29
|
+
3. **Footnote identity.** marked-footnote's output shape (`footnote-N` /
|
|
30
|
+
`footnote-ref-N`, the `data-footnotes` section, back-reference
|
|
31
|
+
stripping) round-trips exactly, including the legacy
|
|
32
|
+
`data-footnote-ref` shape and non-numeric labels (`[^note]`).
|
|
33
|
+
4. **No silent loss.** Unknown elements pass through as raw HTML (stable
|
|
34
|
+
across cycles via markdown's HTML passthrough); every DOM text node
|
|
35
|
+
reaches the output.
|
|
36
|
+
|
|
37
|
+
Guarantee 2 is the hard one, and it is exactly why this package exists:
|
|
38
|
+
most DOM-to-markdown serializers produce output that marked reads back
|
|
39
|
+
*differently* — emphasis that stops pairing, escapes that double every
|
|
40
|
+
cycle, footnotes that vanish a trip late. barkdown's serializer models
|
|
41
|
+
marked's actual parsing behavior (including its documented-by-test
|
|
42
|
+
deviations from CommonMark) and falls back to raw HTML — which is stable
|
|
43
|
+
— whenever a construct cannot be expressed reliably in markdown.
|
|
44
|
+
|
|
45
|
+
## Install
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
npm install @kevinpeckham/barkdown marked marked-footnote
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
`marked` and `marked-footnote` are peer dependencies — you own the
|
|
52
|
+
version, barkdown guarantees against it (see the version policy below).
|
|
53
|
+
|
|
54
|
+
## API
|
|
55
|
+
|
|
56
|
+
```ts
|
|
57
|
+
toMarkdown(input: HTMLElement | string, options?: { adapter?: DomAdapter }): string
|
|
58
|
+
```
|
|
59
|
+
Serialize a DOM subtree (or an HTML string) to GFM markdown. Element
|
|
60
|
+
input is walked directly through a structural interface — browser
|
|
61
|
+
elements, happy-dom, and linkedom all satisfy it. String input needs a
|
|
62
|
+
`document` to parse with: the browser's global one by default, or an
|
|
63
|
+
adapter on the server.
|
|
64
|
+
|
|
65
|
+
```ts
|
|
66
|
+
toDom(markdown: string): string
|
|
67
|
+
```
|
|
68
|
+
Markdown → HTML string via marked (`gfm: true`) + marked-footnote.
|
|
69
|
+
barkdown configures its own `Marked` instance and never mutates the
|
|
70
|
+
`marked` singleton. DOM construction is your side (`innerHTML`, or any
|
|
71
|
+
DOM library).
|
|
72
|
+
|
|
73
|
+
```ts
|
|
74
|
+
roundTrip(markdown: string, adapter?: DomAdapter): string
|
|
75
|
+
```
|
|
76
|
+
`toMarkdown(toDom(markdown))` — one application canonicalizes; a second
|
|
77
|
+
is a fixed point.
|
|
78
|
+
|
|
79
|
+
## Server-side usage (Node, Bun)
|
|
80
|
+
|
|
81
|
+
Runtimes without a global `document` pass one explicitly:
|
|
82
|
+
|
|
83
|
+
```ts
|
|
84
|
+
// happy-dom
|
|
85
|
+
import { Window } from "happy-dom";
|
|
86
|
+
import { documentAdapter, toMarkdown } from "@kevinpeckham/barkdown";
|
|
87
|
+
const adapter = documentAdapter(new Window().document);
|
|
88
|
+
|
|
89
|
+
// linkedom
|
|
90
|
+
import { parseHTML } from "linkedom";
|
|
91
|
+
const adapter = documentAdapter(parseHTML("<html><body></body></html>").document);
|
|
92
|
+
|
|
93
|
+
toMarkdown("<p>Some <strong>html</strong></p>", { adapter });
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
Both are covered by a conformance suite, including the CSS-styled-span
|
|
97
|
+
emphasis path (`style="font-weight: 700"` → `**bold**`).
|
|
98
|
+
|
|
99
|
+
## The canonical dialect
|
|
100
|
+
|
|
101
|
+
What `roundTrip` normalizes any markdown into:
|
|
102
|
+
|
|
103
|
+
- **Headings**: ATX (`## Title`), single-line; setext converts. Emphasis
|
|
104
|
+
inside headings flattens away (editor-safety inherited from the
|
|
105
|
+
serializer's WYSIWYG origins).
|
|
106
|
+
- **Emphasis**: `**bold**`, `*italic*`, `~~strikethrough~~`. Nesting the
|
|
107
|
+
markers can't express reliably (empty content, delimiter runs adjacent
|
|
108
|
+
to punctuation, marked's pairing quirks) serializes as raw inline tags
|
|
109
|
+
(`<em>…</em>`) around markdown-escaped content — which reparses to the
|
|
110
|
+
identical DOM.
|
|
111
|
+
- **Lists**: `-` bullets, tight; loose lists flatten (paragraphs inside
|
|
112
|
+
an item become continuation lines). Ordered lists use `1.` and
|
|
113
|
+
preserve their `start` number. Task lists keep `[x]` / `[ ]`.
|
|
114
|
+
Adjacent same-marker lists are held apart by a round-tripping
|
|
115
|
+
`<!-- -->` comment (a blank line alone would merge them).
|
|
116
|
+
- **Code**: backtick fences with the language info string; inline code
|
|
117
|
+
picks a backtick run longer than any in the content.
|
|
118
|
+
- **Tables**: GFM pipe tables, alignment as `:---` / `:---:` / `---:`,
|
|
119
|
+
pipes escaped in cells.
|
|
120
|
+
- **Breaks**: two-space hard breaks; `---` thematic breaks.
|
|
121
|
+
- **Links**: `[text](dest "title")`; destinations are pre-normalized
|
|
122
|
+
with marked's own `cleanUrl` (encodeURI) transform so they're
|
|
123
|
+
byte-stable. Self-links (`text === href`, incl. `mailto:`) emit as
|
|
124
|
+
bare GFM autolinks when the surrounding context re-linkifies safely
|
|
125
|
+
*and* the URL is already in cleanUrl's canonical percent-encoded form.
|
|
126
|
+
URLs carrying raw unicode or DOM-decoded entities (`…/café`,
|
|
127
|
+
`…#©`) canonicalize to the full form — readable text, encoded
|
|
128
|
+
destination — because re-linkifying them bare would percent-encode
|
|
129
|
+
the href out from under the text.
|
|
130
|
+
- **Images**: ``.
|
|
131
|
+
- **Footnotes**: `[^label]` + `[^label]: text`, multi-paragraph
|
|
132
|
+
definitions via 4-space continuation. Definitions never referenced
|
|
133
|
+
outside the footnote section are dropped (marked-footnote renders them
|
|
134
|
+
to nothing, so they'd silently vanish a trip late otherwise).
|
|
135
|
+
- **Escaping**: `\` `` ` `` `*` `_` `[` `]` `~` `<` always; `&` when it
|
|
136
|
+
reads as a character entity; `#` `>` `|` `-` `+` `N.` only at line
|
|
137
|
+
starts. Bare URLs/emails in plain text are defused (`https\://…`,
|
|
138
|
+
`user\@host`) because marked linkifies them at any position — real
|
|
139
|
+
links serialize as links instead.
|
|
140
|
+
- **Passthrough**: `<div>`/plain `<span>` wrappers are transparent
|
|
141
|
+
(contenteditable artifacts). HTML comments round-trip. Elements on
|
|
142
|
+
CommonMark's HTML-block tag list pass through as raw `outerHTML`;
|
|
143
|
+
other unknown elements are rebuilt attribute-for-attribute around
|
|
144
|
+
their markdown-escaped children.
|
|
145
|
+
|
|
146
|
+
Whitespace inside paragraphs is canonicalized (continuation-line indent
|
|
147
|
+
and trailing whitespace stripped, hard breaks normalized to exactly two
|
|
148
|
+
spaces) — markdown cannot represent the alternatives, so keeping them
|
|
149
|
+
would defeat idempotence.
|
|
150
|
+
|
|
151
|
+
## Sanitization is out of scope
|
|
152
|
+
|
|
153
|
+
`toDom` returns whatever marked produces; `toMarkdown` preserves unknown
|
|
154
|
+
elements as raw HTML *by design*. If any input is untrusted, sanitize
|
|
155
|
+
before rendering or storing — e.g. [DOMPurify](https://github.com/cure53/DOMPurify)
|
|
156
|
+
between `toDom()` and `innerHTML`.
|
|
157
|
+
|
|
158
|
+
## Version policy
|
|
159
|
+
|
|
160
|
+
The identity guarantee is **with respect to the tested marked range**
|
|
161
|
+
(currently `marked@^18`, `marked-footnote@^1.4`). CI runs the full
|
|
162
|
+
round-trip suite against the pinned versions, and marked upgrades land
|
|
163
|
+
only with a green suite. This is barkdown's one ongoing maintenance
|
|
164
|
+
commitment; the API surface is otherwise frozen at v1, like barkup's.
|
|
165
|
+
|
|
166
|
+
## Maintenance posture
|
|
167
|
+
|
|
168
|
+
barkdown is **scoped and stable**: the v1 surface (`toMarkdown` /
|
|
169
|
+
`toDom` / `roundTrip` + the adapter seam) is the whole product, and it
|
|
170
|
+
is intentionally small. Bug reports and guarantee violations are always
|
|
171
|
+
welcome; feature scope is frozen by design.
|
|
172
|
+
|
|
173
|
+
## License & credit
|
|
174
|
+
|
|
175
|
+
MIT © Kevin Peckham. Built at [Lightning Jar](https://www.lightningjar.com).
|
|
176
|
+
Pairs with [@kevinpeckham/barkup](https://github.com/kevinpeckham/barkup) —
|
|
177
|
+
typed trees as HTML, with round-trip guarantees of its own.
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DOM adapter seam. barkdown's serializer walks a DOM subtree through a
|
|
3
|
+
* small structural interface (`HtmlElementLike`), so any standards-shaped
|
|
4
|
+
* DOM works: the browser's, happy-dom's, linkedom's.
|
|
5
|
+
*
|
|
6
|
+
* The adapter is only needed for the string-input branch of
|
|
7
|
+
* `toMarkdown()` (and therefore `roundTrip()`): turning an HTML string
|
|
8
|
+
* into a container element requires a `document`. In browsers the global
|
|
9
|
+
* document is used automatically; on servers pass one explicitly:
|
|
10
|
+
*
|
|
11
|
+
* // happy-dom
|
|
12
|
+
* import { Window } from "happy-dom";
|
|
13
|
+
* const adapter = documentAdapter(new Window().document);
|
|
14
|
+
*
|
|
15
|
+
* // linkedom
|
|
16
|
+
* import { parseHTML } from "linkedom";
|
|
17
|
+
* const adapter = documentAdapter(
|
|
18
|
+
* parseHTML("<html><body></body></html>").document,
|
|
19
|
+
* );
|
|
20
|
+
*/
|
|
21
|
+
/** Error type for barkdown misuse (e.g. no DOM available). */
|
|
22
|
+
export declare class BarkdownError extends Error {
|
|
23
|
+
name: string;
|
|
24
|
+
}
|
|
25
|
+
/** Structural subset of CSSStyleDeclaration the serializer reads. */
|
|
26
|
+
export interface StyleLike {
|
|
27
|
+
fontWeight?: string;
|
|
28
|
+
fontStyle?: string;
|
|
29
|
+
textDecoration?: string;
|
|
30
|
+
textDecorationLine?: string;
|
|
31
|
+
textAlign?: string;
|
|
32
|
+
}
|
|
33
|
+
/** Structural subset of Node the serializer reads. */
|
|
34
|
+
export interface DomNodeLike {
|
|
35
|
+
readonly nodeType: number;
|
|
36
|
+
readonly textContent: string | null;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Structural subset of HTMLElement the serializer reads. Browser
|
|
40
|
+
* `HTMLElement`, happy-dom and linkedom elements all satisfy it.
|
|
41
|
+
*/
|
|
42
|
+
export interface HtmlElementLike extends DomNodeLike {
|
|
43
|
+
readonly tagName: string;
|
|
44
|
+
readonly id: string;
|
|
45
|
+
readonly childNodes: ArrayLike<DomNodeLike>;
|
|
46
|
+
readonly children: ArrayLike<HtmlElementLike>;
|
|
47
|
+
readonly outerHTML: string;
|
|
48
|
+
getAttribute(name: string): string | null;
|
|
49
|
+
/** Optional: used to rebuild unknown elements attribute-for-attribute;
|
|
50
|
+
* without it the serializer falls back to `outerHTML`. */
|
|
51
|
+
getAttributeNames?(): string[];
|
|
52
|
+
hasAttribute(name: string): boolean;
|
|
53
|
+
querySelector(selectors: string): HtmlElementLike | null;
|
|
54
|
+
querySelectorAll(selectors: string): ArrayLike<HtmlElementLike>;
|
|
55
|
+
cloneNode(deep?: boolean): DomNodeLike;
|
|
56
|
+
remove(): void;
|
|
57
|
+
readonly style?: StyleLike;
|
|
58
|
+
}
|
|
59
|
+
/** Structural subset of Document the adapter needs. */
|
|
60
|
+
export interface DocumentLike {
|
|
61
|
+
createElement(tagName: string): HtmlElementLike & {
|
|
62
|
+
innerHTML: string;
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
export interface DomAdapter {
|
|
66
|
+
/**
|
|
67
|
+
* Parse an HTML fragment and return a container element whose
|
|
68
|
+
* children are the fragment's top-level nodes.
|
|
69
|
+
*/
|
|
70
|
+
containerFromHtml(html: string): HtmlElementLike;
|
|
71
|
+
}
|
|
72
|
+
/** Wrap any standards-shaped document (browser, happy-dom, linkedom, …). */
|
|
73
|
+
export declare function documentAdapter(document: DocumentLike): DomAdapter;
|
|
74
|
+
/**
|
|
75
|
+
* Resolve the platform document (browsers). Throws a helpful error in
|
|
76
|
+
* runtimes without one — pass an adapter explicitly there.
|
|
77
|
+
*/
|
|
78
|
+
export declare function defaultAdapter(): DomAdapter;
|
package/dist/adapter.js
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DOM adapter seam. barkdown's serializer walks a DOM subtree through a
|
|
3
|
+
* small structural interface (`HtmlElementLike`), so any standards-shaped
|
|
4
|
+
* DOM works: the browser's, happy-dom's, linkedom's.
|
|
5
|
+
*
|
|
6
|
+
* The adapter is only needed for the string-input branch of
|
|
7
|
+
* `toMarkdown()` (and therefore `roundTrip()`): turning an HTML string
|
|
8
|
+
* into a container element requires a `document`. In browsers the global
|
|
9
|
+
* document is used automatically; on servers pass one explicitly:
|
|
10
|
+
*
|
|
11
|
+
* // happy-dom
|
|
12
|
+
* import { Window } from "happy-dom";
|
|
13
|
+
* const adapter = documentAdapter(new Window().document);
|
|
14
|
+
*
|
|
15
|
+
* // linkedom
|
|
16
|
+
* import { parseHTML } from "linkedom";
|
|
17
|
+
* const adapter = documentAdapter(
|
|
18
|
+
* parseHTML("<html><body></body></html>").document,
|
|
19
|
+
* );
|
|
20
|
+
*/
|
|
21
|
+
/** Error type for barkdown misuse (e.g. no DOM available). */
|
|
22
|
+
export class BarkdownError extends Error {
|
|
23
|
+
name = "BarkdownError";
|
|
24
|
+
}
|
|
25
|
+
/** Wrap any standards-shaped document (browser, happy-dom, linkedom, …). */
|
|
26
|
+
export function documentAdapter(document) {
|
|
27
|
+
return {
|
|
28
|
+
containerFromHtml(html) {
|
|
29
|
+
const container = document.createElement("div");
|
|
30
|
+
container.innerHTML = html;
|
|
31
|
+
return container;
|
|
32
|
+
},
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Resolve the platform document (browsers). Throws a helpful error in
|
|
37
|
+
* runtimes without one — pass an adapter explicitly there.
|
|
38
|
+
*/
|
|
39
|
+
export function defaultAdapter() {
|
|
40
|
+
const doc = globalThis.document;
|
|
41
|
+
if (!doc || typeof doc.createElement !== "function") {
|
|
42
|
+
throw new BarkdownError("No global document in this runtime. Pass an adapter explicitly, " +
|
|
43
|
+
"e.g. `documentAdapter(new Window().document)` with happy-dom, or " +
|
|
44
|
+
'`documentAdapter(parseHTML("<html><body></body></html>").document)` ' +
|
|
45
|
+
"with linkedom.");
|
|
46
|
+
}
|
|
47
|
+
return documentAdapter(doc);
|
|
48
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* barkdown — a Markdown ⇄ DOM round-trip codec, guaranteed to invert
|
|
3
|
+
* marked. Sibling of @kevinpeckham/barkup (barkup guards the tree's
|
|
4
|
+
* identity; barkdown guards the prose's).
|
|
5
|
+
*
|
|
6
|
+
* toMarkdown(input, options?) — DOM subtree (or HTML string) → markdown
|
|
7
|
+
* toDom(markdown) — markdown → HTML string (marked + footnotes)
|
|
8
|
+
* roundTrip(markdown, adapter?) — toMarkdown(toDom(markdown))
|
|
9
|
+
*
|
|
10
|
+
* Guarantees (see README):
|
|
11
|
+
* 1. Canonical identity: toMarkdown(toDom(md)) === md for canonical md.
|
|
12
|
+
* 2. Fixed-point convergence: one round trip canonicalizes; a second is
|
|
13
|
+
* byte-identical to the first.
|
|
14
|
+
* 3. Footnote identity: marked-footnote's output shape round-trips
|
|
15
|
+
* exactly, including the legacy data-footnote-ref shape.
|
|
16
|
+
* 4. No silent loss: unknown elements pass through as raw HTML; every
|
|
17
|
+
* DOM text node reaches the output.
|
|
18
|
+
*/
|
|
19
|
+
import type { DomAdapter } from "./adapter.js";
|
|
20
|
+
/**
|
|
21
|
+
* Parse markdown with marked, serialize the resulting DOM back to
|
|
22
|
+
* markdown. One application canonicalizes; a second is a fixed point.
|
|
23
|
+
*/
|
|
24
|
+
export declare function roundTrip(markdown: string, adapter?: DomAdapter): string;
|
|
25
|
+
export type { DocumentLike, DomAdapter, DomNodeLike, HtmlElementLike, StyleLike, } from "./adapter.js";
|
|
26
|
+
export { BarkdownError, defaultAdapter, documentAdapter, } from "./adapter.js";
|
|
27
|
+
export { toDom } from "./parse.js";
|
|
28
|
+
export type { ToMarkdownOptions } from "./serialize.js";
|
|
29
|
+
export { toMarkdown } from "./serialize.js";
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* barkdown — a Markdown ⇄ DOM round-trip codec, guaranteed to invert
|
|
3
|
+
* marked. Sibling of @kevinpeckham/barkup (barkup guards the tree's
|
|
4
|
+
* identity; barkdown guards the prose's).
|
|
5
|
+
*
|
|
6
|
+
* toMarkdown(input, options?) — DOM subtree (or HTML string) → markdown
|
|
7
|
+
* toDom(markdown) — markdown → HTML string (marked + footnotes)
|
|
8
|
+
* roundTrip(markdown, adapter?) — toMarkdown(toDom(markdown))
|
|
9
|
+
*
|
|
10
|
+
* Guarantees (see README):
|
|
11
|
+
* 1. Canonical identity: toMarkdown(toDom(md)) === md for canonical md.
|
|
12
|
+
* 2. Fixed-point convergence: one round trip canonicalizes; a second is
|
|
13
|
+
* byte-identical to the first.
|
|
14
|
+
* 3. Footnote identity: marked-footnote's output shape round-trips
|
|
15
|
+
* exactly, including the legacy data-footnote-ref shape.
|
|
16
|
+
* 4. No silent loss: unknown elements pass through as raw HTML; every
|
|
17
|
+
* DOM text node reaches the output.
|
|
18
|
+
*/
|
|
19
|
+
import { toDom } from "./parse.js";
|
|
20
|
+
import { toMarkdown } from "./serialize.js";
|
|
21
|
+
/**
|
|
22
|
+
* Parse markdown with marked, serialize the resulting DOM back to
|
|
23
|
+
* markdown. One application canonicalizes; a second is a fixed point.
|
|
24
|
+
*/
|
|
25
|
+
export function roundTrip(markdown, adapter) {
|
|
26
|
+
return toMarkdown(toDom(markdown), adapter ? { adapter } : {});
|
|
27
|
+
}
|
|
28
|
+
export { BarkdownError, defaultAdapter, documentAdapter, } from "./adapter.js";
|
|
29
|
+
export { toDom } from "./parse.js";
|
|
30
|
+
export { toMarkdown } from "./serialize.js";
|
package/dist/parse.d.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Render markdown to an HTML string with marked (GFM) + marked-footnote.
|
|
3
|
+
* This is the parse half of the codec that `toMarkdown` inverts.
|
|
4
|
+
*
|
|
5
|
+
* barkdown configures its own `Marked` instance — it never mutates the
|
|
6
|
+
* `marked` singleton, so a consumer's own marked configuration is
|
|
7
|
+
* untouched.
|
|
8
|
+
*
|
|
9
|
+
* Footnotes emit `<sup><a data-footnote-ref …>` refs + a trailing
|
|
10
|
+
* `<section data-footnotes>` — which `toMarkdown` inverts back to `[^N]`
|
|
11
|
+
* + `[^N]: text` GFM footnote syntax.
|
|
12
|
+
*
|
|
13
|
+
* NOTE: no sanitization is performed (marked does not sanitize).
|
|
14
|
+
* Sanitize the output (e.g. DOMPurify) before trusting it in a live DOM.
|
|
15
|
+
*/
|
|
16
|
+
/** Markdown → HTML string. DOM construction is the consumer's side. */
|
|
17
|
+
export declare function toDom(markdown: string): string;
|
package/dist/parse.js
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Render markdown to an HTML string with marked (GFM) + marked-footnote.
|
|
3
|
+
* This is the parse half of the codec that `toMarkdown` inverts.
|
|
4
|
+
*
|
|
5
|
+
* barkdown configures its own `Marked` instance — it never mutates the
|
|
6
|
+
* `marked` singleton, so a consumer's own marked configuration is
|
|
7
|
+
* untouched.
|
|
8
|
+
*
|
|
9
|
+
* Footnotes emit `<sup><a data-footnote-ref …>` refs + a trailing
|
|
10
|
+
* `<section data-footnotes>` — which `toMarkdown` inverts back to `[^N]`
|
|
11
|
+
* + `[^N]: text` GFM footnote syntax.
|
|
12
|
+
*
|
|
13
|
+
* NOTE: no sanitization is performed (marked does not sanitize).
|
|
14
|
+
* Sanitize the output (e.g. DOMPurify) before trusting it in a live DOM.
|
|
15
|
+
*/
|
|
16
|
+
import { Marked } from "marked";
|
|
17
|
+
import markedFootnote from "marked-footnote";
|
|
18
|
+
// Lazy singleton: the footnote extension registers exactly once, and
|
|
19
|
+
// only when toDom is first used.
|
|
20
|
+
let instance;
|
|
21
|
+
function getMarked() {
|
|
22
|
+
if (!instance) {
|
|
23
|
+
instance = new Marked({ gfm: true }).use(markedFootnote());
|
|
24
|
+
}
|
|
25
|
+
return instance;
|
|
26
|
+
}
|
|
27
|
+
/** Markdown → HTML string. DOM construction is the consumer's side. */
|
|
28
|
+
export function toDom(markdown) {
|
|
29
|
+
if (!markdown)
|
|
30
|
+
return "";
|
|
31
|
+
return getMarked().parse(markdown, { async: false });
|
|
32
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Emphasis-delimiter machinery: decide whether inline content can be
|
|
3
|
+
* wrapped in `**` / `*` / `~~` markers and still reparse (via marked) to
|
|
4
|
+
* the same bytes. Encodes CommonMark flanking rules plus the empirical
|
|
5
|
+
* quirks of marked's emStrong/del run accounting. Callers fall back to
|
|
6
|
+
* raw inline tags when wrapping is unrepresentable (`tryWrapDelimited`
|
|
7
|
+
* returns null).
|
|
8
|
+
*/
|
|
9
|
+
import type { DomNodeLike } from "./adapter.js";
|
|
10
|
+
/**
|
|
11
|
+
* Wrap inline content in an emphasis delimiter, or return null when the
|
|
12
|
+
* result would not reparse as written:
|
|
13
|
+
* - CommonMark delimiters can't face whitespace, so leading/trailing
|
|
14
|
+
* whitespace is hoisted outside the markers; empty or whitespace-only
|
|
15
|
+
* content is unrepresentable (`****` reparses as literal asterisks).
|
|
16
|
+
* - Content whose edges already carry *unescaped* marker characters
|
|
17
|
+
* (nested emphasis) merges into a longer delimiter run. Symmetric
|
|
18
|
+
* runs reparse to the same bytes; asymmetric ones (`**0*!*`) do not.
|
|
19
|
+
* Tilde runs beyond `~~` never delimit, so any edge tilde bails.
|
|
20
|
+
* (`escapeMarkdownText` escapes `*`/`~` in text, so a backslash-preceded
|
|
21
|
+
* edge char is literal text, not a delimiter — hence the lookbehind.)
|
|
22
|
+
*/
|
|
23
|
+
export declare function tryWrapDelimited(inner: string, marker: string, before: string, next: DomNodeLike | undefined): string | null;
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Emphasis-delimiter machinery: decide whether inline content can be
|
|
3
|
+
* wrapped in `**` / `*` / `~~` markers and still reparse (via marked) to
|
|
4
|
+
* the same bytes. Encodes CommonMark flanking rules plus the empirical
|
|
5
|
+
* quirks of marked's emStrong/del run accounting. Callers fall back to
|
|
6
|
+
* raw inline tags when wrapping is unrepresentable (`tryWrapDelimited`
|
|
7
|
+
* returns null).
|
|
8
|
+
*/
|
|
9
|
+
const ELEMENT_NODE = 1;
|
|
10
|
+
const TEXT_NODE = 3;
|
|
11
|
+
/**
|
|
12
|
+
* Wrap inline content in an emphasis delimiter, or return null when the
|
|
13
|
+
* result would not reparse as written:
|
|
14
|
+
* - CommonMark delimiters can't face whitespace, so leading/trailing
|
|
15
|
+
* whitespace is hoisted outside the markers; empty or whitespace-only
|
|
16
|
+
* content is unrepresentable (`****` reparses as literal asterisks).
|
|
17
|
+
* - Content whose edges already carry *unescaped* marker characters
|
|
18
|
+
* (nested emphasis) merges into a longer delimiter run. Symmetric
|
|
19
|
+
* runs reparse to the same bytes; asymmetric ones (`**0*!*`) do not.
|
|
20
|
+
* Tilde runs beyond `~~` never delimit, so any edge tilde bails.
|
|
21
|
+
* (`escapeMarkdownText` escapes `*`/`~` in text, so a backslash-preceded
|
|
22
|
+
* edge char is literal text, not a delimiter — hence the lookbehind.)
|
|
23
|
+
*/
|
|
24
|
+
export function tryWrapDelimited(inner, marker, before, next) {
|
|
25
|
+
const match = inner.match(/^(\s*)([\s\S]*?)(\s*)$/);
|
|
26
|
+
if (!match)
|
|
27
|
+
return null;
|
|
28
|
+
const lead = match[1] ?? "";
|
|
29
|
+
const core = match[2] ?? "";
|
|
30
|
+
const trail = match[3] ?? "";
|
|
31
|
+
if (core === "")
|
|
32
|
+
return null;
|
|
33
|
+
const isTilde = marker[0] === "~";
|
|
34
|
+
const delimiterChar = isTilde ? "~" : "*";
|
|
35
|
+
if (hasUnsafeMarkerRuns(core, isTilde))
|
|
36
|
+
return null;
|
|
37
|
+
// Adjacency: a neighboring same-kind character — escaped or not, in
|
|
38
|
+
// either direction — merges into (or corrupts) the delimiter run.
|
|
39
|
+
if (lead === "" && before.endsWith(delimiterChar))
|
|
40
|
+
return null;
|
|
41
|
+
if (trail === "" && peekNextChar(next) === delimiterChar)
|
|
42
|
+
return null;
|
|
43
|
+
if (hasMarkedPairingHazard(core, marker.length, isTilde))
|
|
44
|
+
return null;
|
|
45
|
+
if (flankingFails(core, lead, trail, before, next))
|
|
46
|
+
return null;
|
|
47
|
+
return lead + marker + core + marker + trail;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Interior shapes that empirically corrupt marked's delimiter pairing:
|
|
51
|
+
* - Interior delimiter runs (nested markers): the wrapper's opener can
|
|
52
|
+
* pair early with the first interior run when that run is
|
|
53
|
+
* closer-capable and CommonMark's rule of three doesn't block the
|
|
54
|
+
* pairing ("**" + interior "**" mispairs; "**" + "*" is blocked —
|
|
55
|
+
* which is why plain bold-with-italic content stays wrappable).
|
|
56
|
+
* - Interior runs directly adjacent to punctuation corrupt marked's
|
|
57
|
+
* pairing regardless of CommonMark's flanking math (`*!**0**0*` and
|
|
58
|
+
* `*a**b**!*` fail while `**a*b*c**` and `**a *b* c**` pair fine).
|
|
59
|
+
* - Raw inline tags combined with interior marker characters corrupt
|
|
60
|
+
* marked's run scanning (`*<c>**x**</c>*` fails to pair while
|
|
61
|
+
* `*<c>x</c>*` is fine). Text `<` is always escaped, so an unescaped
|
|
62
|
+
* `<` here is one of our own raw-tag emissions.
|
|
63
|
+
* - A trailing escape pair (e.g. `…\~`) corrupts marked's closing-run
|
|
64
|
+
* scan the same way escaped marker chars do.
|
|
65
|
+
*/
|
|
66
|
+
function hasMarkedPairingHazard(core, markerLength, isTilde) {
|
|
67
|
+
if (!isTilde && firstInteriorRunSteals(core, markerLength))
|
|
68
|
+
return true;
|
|
69
|
+
if (!isTilde && interiorRunTouchesPunctuation(core))
|
|
70
|
+
return true;
|
|
71
|
+
if (/(?<!\\)</.test(core) && (isTilde ? /~/ : /\*/).test(core))
|
|
72
|
+
return true;
|
|
73
|
+
return /\\[\s\S]$/.test(core);
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Marker-run hazards inside the core content:
|
|
77
|
+
* - Edge delimiter runs. marked's emStrong run accounting does not honor
|
|
78
|
+
* backslash escapes adjacent to a run, so an *escaped* edge star is
|
|
79
|
+
* just as hazardous as a real one. Symmetric unescaped asterisk runs
|
|
80
|
+
* reparse to the same bytes (`***x***`); anything else bails to the
|
|
81
|
+
* raw-tag fallback. Tilde runs beyond `~~` never delimit, so any edge
|
|
82
|
+
* tilde bails.
|
|
83
|
+
* - marked's emStrong/del run arithmetic miscounts around
|
|
84
|
+
* backslash-escaped marker characters (anywhere in the content, not
|
|
85
|
+
* just at the edges), so any escaped marker char bails. Strikethrough
|
|
86
|
+
* pairs additionally match first-come — a `~~` anywhere inside the
|
|
87
|
+
* core would close the wrap early.
|
|
88
|
+
*/
|
|
89
|
+
function hasUnsafeMarkerRuns(core, isTilde) {
|
|
90
|
+
const leadRun = core.match(isTilde ? /^~+/ : /^\*+/)?.[0].length ?? 0;
|
|
91
|
+
const trailRun = core.match(isTilde ? /~+$/ : /\*+$/)?.[0].length ?? 0;
|
|
92
|
+
const escapedMarkerChar = isTilde ? /\\~/.test(core) : /\\\*/.test(core);
|
|
93
|
+
const tildeInterior = isTilde && /~~/.test(core);
|
|
94
|
+
return (escapedMarkerChar ||
|
|
95
|
+
tildeInterior ||
|
|
96
|
+
(isTilde ? leadRun > 0 || trailRun > 0 : leadRun !== trailRun));
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* CommonMark flanking (and marked applies the same rules to `~~`): a
|
|
100
|
+
* delimiter followed by punctuation only *opens* if preceded by
|
|
101
|
+
* whitespace/punctuation, and one preceded by punctuation only *closes*
|
|
102
|
+
* if followed by whitespace/punctuation.
|
|
103
|
+
*/
|
|
104
|
+
function flankingFails(core, lead, trail, before, next) {
|
|
105
|
+
const beforeChar = lead !== "" ? " " : before.slice(-1);
|
|
106
|
+
const afterChar = trail !== "" ? " " : peekNextChar(next);
|
|
107
|
+
const coreFirst = core.slice(0, 1);
|
|
108
|
+
const coreLast = core.slice(-1);
|
|
109
|
+
const openFails = isPunctuation(coreFirst) &&
|
|
110
|
+
!(isFlankWhitespace(beforeChar) || isPunctuation(beforeChar));
|
|
111
|
+
const closeFails = isPunctuation(coreLast) &&
|
|
112
|
+
!(isFlankWhitespace(afterChar) || isPunctuation(afterChar));
|
|
113
|
+
return openFails || closeFails;
|
|
114
|
+
}
|
|
115
|
+
/** Iterate the interior `*`-runs of `core` (edge runs merge — skipped). */
|
|
116
|
+
function* interiorStarRuns(core) {
|
|
117
|
+
for (const m of core.matchAll(/\*+/g)) {
|
|
118
|
+
const start = m.index ?? 0;
|
|
119
|
+
const end = start + m[0].length;
|
|
120
|
+
if (start === 0 || end === core.length)
|
|
121
|
+
continue; // edge runs merge
|
|
122
|
+
yield { run: m[0], prev: core[start - 1] ?? "", next: core[end] ?? "" };
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* Would the first interior `*`-run of `core` close against a wrapper
|
|
127
|
+
* opener of `markerLength` stars? Closer-capable = not preceded by
|
|
128
|
+
* whitespace and (not preceded by punctuation, or followed by
|
|
129
|
+
* whitespace/punctuation). The rule of three blocks the pairing when the
|
|
130
|
+
* combined lengths are a multiple of 3 (and the run lengths aren't).
|
|
131
|
+
*/
|
|
132
|
+
function firstInteriorRunSteals(core, markerLength) {
|
|
133
|
+
for (const { run, prev, next } of interiorStarRuns(core)) {
|
|
134
|
+
if (prev === "\\")
|
|
135
|
+
return false; // escaped → already bailed upstream
|
|
136
|
+
const precededByWhitespace = /\s/.test(prev);
|
|
137
|
+
const precededByPunct = isPunctuation(prev);
|
|
138
|
+
const canClose = !precededByWhitespace &&
|
|
139
|
+
(!precededByPunct || /\s/.test(next) || isPunctuation(next));
|
|
140
|
+
if (!canClose)
|
|
141
|
+
return false; // first run opens; later runs pair inward
|
|
142
|
+
return (markerLength + run.length) % 3 !== 0;
|
|
143
|
+
}
|
|
144
|
+
return false;
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* True when any interior (non-edge) `*`-run has punctuation immediately
|
|
148
|
+
* on either side — the empirically unsafe shape in marked's emphasis
|
|
149
|
+
* pairing. (Whitespace- and alphanumeric-adjacent runs pair reliably.)
|
|
150
|
+
*/
|
|
151
|
+
function interiorRunTouchesPunctuation(core) {
|
|
152
|
+
for (const { prev, next } of interiorStarRuns(core)) {
|
|
153
|
+
if (isPunctuation(prev) || isPunctuation(next))
|
|
154
|
+
return true;
|
|
155
|
+
}
|
|
156
|
+
return false;
|
|
157
|
+
}
|
|
158
|
+
/** Start/end of block counts as whitespace for flanking purposes. */
|
|
159
|
+
function isFlankWhitespace(char) {
|
|
160
|
+
return char === "" || /\s/.test(char);
|
|
161
|
+
}
|
|
162
|
+
function isPunctuation(char) {
|
|
163
|
+
return /[\p{P}\p{S}]/u.test(char);
|
|
164
|
+
}
|
|
165
|
+
/**
|
|
166
|
+
* First character that will follow the current construct in the emitted
|
|
167
|
+
* markdown — used for flanking checks. Unknown elements return a
|
|
168
|
+
* conservative non-space, non-punctuation placeholder.
|
|
169
|
+
*/
|
|
170
|
+
function peekNextChar(next) {
|
|
171
|
+
if (!next)
|
|
172
|
+
return ""; // end of the inline run — block boundary
|
|
173
|
+
if (next.nodeType === TEXT_NODE) {
|
|
174
|
+
return (next.textContent ?? "").slice(0, 1);
|
|
175
|
+
}
|
|
176
|
+
if (next.nodeType === ELEMENT_NODE) {
|
|
177
|
+
const tag = next.tagName.toLowerCase();
|
|
178
|
+
if (tag === "br")
|
|
179
|
+
return "\n";
|
|
180
|
+
// Unknown until serialized: treat as a letter so the flanking
|
|
181
|
+
// check stays conservative (more raw-tag fallbacks, never a broken
|
|
182
|
+
// delimiter).
|
|
183
|
+
return "a";
|
|
184
|
+
}
|
|
185
|
+
return "";
|
|
186
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Post-processing pass for footnote definitions in serialized markdown.
|
|
3
|
+
*
|
|
4
|
+
* marked-footnote renders nothing for a definition that is never
|
|
5
|
+
* referenced outside the footnote section itself, so emitting such a
|
|
6
|
+
* definition would vanish one trip late. This pass canonicalizes the
|
|
7
|
+
* output by dropping unreferenced definitions up front. Pure
|
|
8
|
+
* string → string; no DOM involvement.
|
|
9
|
+
*/
|
|
10
|
+
/**
|
|
11
|
+
* Drop footnote definitions that nothing references. Dropping a
|
|
12
|
+
* definition can orphan another (its content held the only ref) —
|
|
13
|
+
* iterate to a fixpoint. Bounded by the number of definitions.
|
|
14
|
+
*/
|
|
15
|
+
export declare function dropUnreferencedFootnoteDefs(markdown: string): string;
|